feat: factor refactor D5b — evaluation contract v1.0, diagnostics channel, 14:50 visibility guards (C4 halted, see body) - #99
Merged
Conversation
…ds + basis-column guard (D5 C3) The adjudication core is upgraded, not rewritten. v0.9 could describe a factor and one evaluation of it but could not say WHICH information set the values were taken under — survivable while every run used one basis, not survivable since PR #79 started scoring the same factor on two, because two reports then carried the same verdict word for two different statistical claims. v1.0 adds exactly two identity fields and renders four declarations that were already mandatory on the factor side: * EvalConfig.view / EvalConfig.return_basis, validated as a LEGAL PAIRING at construction through data.availability_policy.require_legal_pairing (the D0 single source — the rule is delegated, not restated). A close-view factor scored on exec_to_exec is now a readable error instead of a convention. * the provenance box states requires / adjustment / overnight_boundary / lookback_depth as named rows rather than leaving them to vars(spec)'s reprs, and an EMPTY requires declaration is marked rather than rendered as a blank row. * analytics/eval/summary.py: any cross-basis verdict table REFUSES to render without the identity columns, and refuses a declared-but-blank one too (R24). Filling a missing basis in would be the silent degradation the project forbids; guessing it from the majority of rows is worse than refusing. WHAT DID NOT MOVE, and is now pinned by value rather than by prose: every VerdictThresholds default (min_abs_icir=0.30, min_incremental_abs_icir=0.15, min_monotonicity_spearman=0.0, the three-part sample gate). Design v3.2 §十 R24 forbids re-calibrating a bar inside the refactor; a threshold that moved here would make every verdict in this cycle uninterpretable, and would do it silently. The artifact drift this causes (2 keys in eval_config, 1 top-level key, 4 Markdown provenance rows — all additions, no deletions, no metric change) is registered in docs/factors/d5_runner_difference_catalogue.md §七之二 BEFORE the C5 reconciliation runs, so it cannot later be chased as a D5 regression. That section also states the consequence the plan did not: byte-for-byte equality of the 22 md/json artifacts is NOT available as a C5 pass condition, and treating it as one would read this upgrade as a regression. Mutation evidence (run, not claimed): * remove the _check_view_basis call -> 4 tests FAIL (rc=1), restored -> pass; * narrow REQUIRED_SUMMARY_COLUMNS to ("view",) -> the return_basis-omitted case and the blank-cell case FAIL, restored -> pass. Gates: pytest 2210 passed / ruff clean / 31 configs / phase0 ic 0.9600 annual 0.8408 unchanged / exec baseline --verify 77/77 at head 45c14aa / frozen panel mtimes untouched.
…(D5 C4 groundwork)
FOUND WHILE SCOPING C4, and the reason this is a separate commit: four of the
eleven minute factors publish a scarcity / neutralization disclosure as an ADDED
report section, and three of them build it from a per-symbol DAY-LEVEL frame the
factor's compute function emits through its diagnostics_out sink. The old eval
runners had that frame because they ran the compute themselves. The unified runner
will not: values come from the D3 store through the D4/D4b materializer, and the
store persists VALUES ONLY. The day counts are not in it and cannot be recovered
from it.
That left exactly two fates for the disclosure — recompute it, or drop it. Dropping
a disclosure because the plumbing changed is the silent degradation this project
forbids, so the channel exists and its cost is stated wherever it is paid:
* MinuteStreamBinding gains an OPTIONAL per_symbol_diagnostics callable, kept
SEPARATE from per_symbol on purpose — per_symbol is the value path D4b verified
cell for cell, and a diagnostics request must not be able to change it. Asking
re-runs the per-symbol math on bars already in memory: no extra I/O, no value
effect. Bound for the three factors that publish a frame; the others answer
has_minute_diagnostics() == False and return an EMPTY frame, which a caller must
never read as "no attrition" (hence the separate, explicit predicate).
* materialize_range(..., diagnostics=sink) — default None is the D4b path byte for
byte. The sink is EMIT-WINDOW scoped, so the denominator keeps the meaning it has
in the frozen artifacts ("the days this run scored"), not "the days that were
loaded"; for a pooled factor only the TERMINAL saturation load is observed, since
the expansion steps are the search, not the computation. A daily factor plus a
sink is a readable error rather than an empty sink.
* service.panel(..., diagnostics=sink) materializes the WHOLE requested range even
on a full store hit, and refuses a shared sink for several factors (the frames
carry no factor label, so a summarizer would silently reduce the mixture).
COST, stated rather than buried: three of eleven factors can never be served from a
warm store when their disclosure is wanted. That is a permanent charge the D5 plan
did not price, and it is the honest half of the trade above.
Mutation evidence (run, not claimed; each mutation asserted to hit its target):
* _sink_diagnostics returns an empty frame -> the VACUITY pre-assertion in the
values-unchanged test FAILS (rc=1) instead of the equality passing for the wrong
reason — the sink-stayed-empty shape is what makes such a test unfailable;
* drop the emit-window slice -> the scoping test FAILS with 576 loaded days;
* remove the sink-forces-refill branch -> the warm-store test FAILS on calls == 0.
Gates: pytest 2210 passed / ruff clean / 31 configs / phase0 ic 0.9600 annual 0.8408
unchanged / exec baseline --verify 77/77 / frozen panel mtimes untouched.
…ers that omit it RECORDING MY OWN DEFECT, one commit old. 6046bbe defaulted EvalConfig.view / return_basis to decision / exec_to_exec — the pairing the design is converging on, and the wrong default for the code that exists today. Each of the eleven live runners builds ONE EvalConfig (_build_eval_config) and hands the SAME object to its close_to_close reports AND, through qt.exec_basis_eval, to its exec ones. With the flattering default, every close artifact would have declared a basis it was not scored on — the describe-the-check drift of #76/#78/#82, introduced by the very field added to prevent it, in the same commit that added it. Nothing had re-run yet, so no artifact carries the false label; the fix is before the fact. A default's only job is to be TRUE for the callers that omit the field. Those callers are on the close basis, so the default is close / close_to_close, and the exec path states its identity EXPLICITLY — mirroring what qt.exec_basis_eval already does one line earlier for the SPEC (intraday_spec_variant). When the close runners retire in C6, flipping the default is a one-line change with no false statement left behind. Pinned: a test asserts the default IS the legacy pairing and says why, and a structural test asserts qt.exec_basis_eval restates the identity rather than inheriting the caller's. The identity phrase test now covers both pairings, so the author-once helper cannot be right for one and wrong for the other.
…corr as the one exception FOUND BY MEASURING BEFORE COMMITTING TO THE MULTI-HOUR C5 RUN. qt/panel_leg_probe.py compares the D4/D4b materializer's values on the real evaluation plane against the D1 frozen panel — the fourth leg's comparison, taken as a probe first (the D5a discipline that stopped D5 being started blind). The expectation was a small, front-loaded warm-up difference. jump_amount_corr_20 came back with 1,155,500 of 1,158,382 both-finite cells differing, 931,394 of them AFTER year one where warm-up cannot be the explanation, max relative difference 1.99 on a correlation bounded in [-1, 1]. CHASED RATHER THAN EXPLAINED AWAY, and the cause is not the engine: * reproducing the OLD runner's exact read + compute for one symbol matches the frozen panel EXACTLY (1210 cells, max |diff| 0.000e+00) — the baseline is reproducible and the comparison target is right; * the same symbol under the new CLOSE view differs in 19 of 1210 cells (the warm-up difference that WAS expected); under the DECISION view, all 1210 differ; * so the whole difference is the 14:50 truncation the decision view applies — and compute_jump_amount_corr is the ONLY one of the eleven with no decision-time truncation of its own (zero decision_time / prepare_visible_minute_bars references in its module; the other ten have 4-10 each), so the old runners handed it 09:30-15:00 of day d. THE DECISIVE TEST, now committed. Perturb ONLY the bars strictly after the cutoff, assert every pre-cutoff row byte-identical, ask whether the day-d value moves. On the real cache (12 CSI500 names, 2021-07..2021-12, 361,500 bars, 4.6% post-cutoff): nine factors moved 0 cells at max |diff| 0.000e+00; jump_amount_corr_20 moved 1,477 of 1,477 at max |diff| 1.276. The committed test reproduces the same split on synthetic bars with a realistic 09:31-11:30 / 13:01-15:00 session (a session ending before 14:50 would make the perturbation a no-op — refused by a pre-assertion). WHAT IT MEANS, stated and not fixed. Under exec_to_exec — the only basis the D5/D7 contract keeps — the entry anchor for date d is d's 14:51 execution bar, so a value built from d's 14:50-15:00 bars uses information from after its own entry. PR #79's merge-time self-check asserted the opposite in general ("the factor is known at 14:50"); for this one factor that is false, and #79's review never returned. Its exec report also DECLARES decision_cutoff=14:50 (via intraday_spec_variant), which its values do not honour. On close_to_close, entry is close(d) and using day d is the conventional, already-declared-optimistic boundary, not a new defect. Flagged, not repaired, per the D5a precedent for found defects: truncating it would change a published factor's values and could move its verdict — a research decision with a pre-registered hypothesis attached, not a refactor's business (§〇). The exception is asserted POSITIVELY, so a later "fix" turns the test red and forces the call to be made deliberately rather than noticed afterwards. CONSEQUENCE FOR C5 the plan did not anticipate: for this factor the frozen D1 panel and the new decision-view engine differ wholesale, and the correct engine is the NEW one. The fourth leg's "relative <= 1e-12" cannot be the pass condition here; the attribution is "the baseline had no 14:50 truncation for this factor", and making the new engine reproduce the baseline would be reproducing the leak.
…o runs, two claims Review APPROVE-WITH-NITS follow-up: MEDIUM-1, MEDIUM-2, LOW-1. MEDIUM-1 — the test that claimed to pin cc06d32 could not. It matched SUBSTRINGS in the function's source (`"replace(" in source and "EXEC_TO_EXEC" in source and "DECISION" in source`). Review broke it by hand: gut the assignment to a bare `replace(eval_cfg)`, leave the two tokens in a COMMENT — the exec path then inherits the caller's CLOSE identity and every exec artifact declares `view=close/close_to_close` while being scored on exec returns. 18 tests in the file passed; 52 across the three files that import `exec_basis_eval` passed. #78's rule again (a lexical assertion cannot hold a semantic claim), failing in the worst direction: it waved through exactly the defect the commit under test had just fixed. It also had a false-positive direction — refactoring that line into a helper deletes the tokens and reddens a correct build. The identity restatement is now the extracted `exec_identity()` and the claim is BEHAVIOURAL: close-default config in, `view=decision` / `return_basis=exec_to_exec` out, and every other field bit-identical. MEDIUM-2 — the exec path declared `view=decision` unconditionally, and on two classes of run that sentence was false. `view` is defined as "the information set the FACTOR VALUES were taken under", but a with-book run has TWO information sets: the subject factor's and the book's (value_ep / value_bp / volatility_20 take close(d) and the evening daily_basic(d) — design §1.1's live defect, open until D7). One field silently reported the clean one. That is the "report declares a check it did not do" failure this field exists to prevent, one level up. Free to fix now because no artifact carries it yet; after C5/C6 it would be baked into 22 files. Chose the coordinator's (1)+(2): derive what can be derived, register what cannot. * `EvalConfig.book_view` (None = no book) — the book's view, declared by the ONLY party that knows it, the caller. `run_exec_basis_evaluation(book_view="close")` defaults to what every current caller actually does (the J4 rule: a default must be true for the callers that omit it). The no-book and with-book runs now get SEPARATE configs, so neither has to speak for the other. Deliberately NOT pairing-checked against the basis: a close-view book under an exec basis IS the disclosure, and refusing it would not un-mix the information sets, only stop the artifact from saying they are mixed. * `subject_view()` derives the SUBJECT's view from a measured fact — `factors.compute.minute.binding.NOT_DECISION_CUTOFF_SAFE`, a deny list of factors whose compute applies no 14:50 truncation. It lives with the factors (it is a fact about values, not about the eval path) and tests/test_decision_cutoff_visibility.py MEASURES it; the test's local copy of the name is gone, because two copies are two things to keep in step and the failure mode of drifting apart is an artifact declaring an information set its values do not have. * Consequence, intended and loud: `jump_amount_corr_20` yields (close, exec_to_exec), which `EvalConfig` refuses. Correct — until that factor is truncated there IS no honest exec artifact of it, and a blocked run beats a false one (red line #9). The generic pairing message is re-raised with the specific reason so nobody hunts for a config typo. The separate correctness-fix PR clears it by dropping one entry from the deny list. LOW-1 — `test_identity_phrase_is_authored_once` asserted the phrase's FORMAT, not that no second place spells it. The property does hold (one producer, three call sites) but a name must not claim more than its assertions: renamed to `test_identity_phrase_covers_both_pairings_and_states_the_book`. A GAP I FOUND IN MY OWN NEW TESTS, and closed. Unit-testing `exec_identity` says nothing about how the caller WIRES it: passing `book_view=None` for the with-book run — a book was supplied, the report says there was none — passed all 28 tests. The end-to-end assertion now lives in tests/test_exec_basis_eval.py, reads both written JSONs, and checks the rendered Markdown says it too. Same shape as MEDIUM-1, one level down; recorded rather than quietly patched. Mutation evidence (every mutation first asserted to change its target): A gut `exec_identity` to `replace(eval_cfg)` (the review's own escape) -> 3 FAIL (was: 52 pass) rc=1 -> restored pass B with-book run gets `book_view=None` -> pre-check: 28 passed (the gap, proven before fixing); after adding the wiring test -> 1 FAIL `assert None == 'close'` rc=1 -> pass C empty `NOT_DECISION_CUTOFF_SAFE` -> 3 FAIL, incl. the MEASUREMENT test finding jump dirty rc=1 -> restored pass D `subject_view` back to a hard-coded `View.DECISION` -> 1 FAIL rc=1 -> restored pass Catalogue §七之二 updated BEFORE any artifact regenerates: `eval_config` is +3 keys (not +2), the no_book/with_book blocks now legitimately differ in `book_view`, and the jump_amount_corr exec block is registered as an intended loud refusal. Gates: pytest 2228 passed / ruff clean / 31 configs / phase0 ic 0.9600 annual 0.8408 unchanged / provisional anchors green / exec baseline --verify 77/77 at 45c14aa / frozen panel mtimes untouched.
StackOverFlow11
added a commit
that referenced
this pull request
Jul 25, 2026
docs: record refactor step D5b (PR #99) and the confirmed jump_amount_corr look-ahead
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Factor-layer refactor step D5, part B: the evaluation contract v1.0 (C3), the diagnostics channel the unified runner will need, and the 14:50 intraday-visibility property tests. C4 was halted, C5/C6 were not started, and nothing was deleted — the halt is the most important thing in this PR and is explained below.
Why C4 stopped
Scoping C4 turned up a confirmed look-ahead defect plus three premise problems that decide the shape of the unified runner. Building half a runner on top of unresolved premises would have been worse than stopping, and truncating the factor to "make the run work" would have smuggled a correction to a published result into a refactor commit — the exact shape this project keeps catching. So: report, don't smuggle.
The look-ahead (confirmed independently, two parties, different symbols and windows)
jump_amount_corr_20is the only one of the eleven minute factors that does not truncate at 14:50 (decision_time/prepare_visible_minute_barsreferences: 0, versus 4–10 for the other ten). The old runner reads[start, end 23:59:59]— whole-day bars — and hands them straight tocompute_jump_amount_corr, with no truncation anywhere downstream.Under
exec_to_execthe day-d entry anchor is the VWAP of the 14:51 bar on day d, so a day-d value computed from 14:50–15:00 bars reaches past its own entry anchor. Perturbation tests (both parties assert up front that post-cutoff rows really changed and pre-cutoff rows are byte-identical):valley_price_quantile_stats,mmp_ew): 0bar_end >= 14:51That last row is the decisive one: the signal sees the very minute it trades in, and the nine after it.
Meanwhile the published artifact declares the check it did not perform —
spec.decision_cutoff="14:50:00",spec.return_basis="exec_to_exec",execution_window="[14:51:00,14:56:59]"— and awards Watch with the predictive axis PASS.qt/exec_basis_eval.py'sdecision_cutoffcomes from the execution parameters and has nothing to do with how the factor was computed.Why no existing test caught it:
test_pit_perturbing_future_bars_does_not_change_factor_at_dguards the cross-day boundary, and its fixture session is six bars at 09:31–09:36 — there is no 14:50 boundary in it at all. The intraday boundary only became load-bearing when PR #79 moved the entry anchor fromclose(d)to14:51(d), and #79's review never returned; this falls inside its "not independently checked" list.The correction is handled by a separate correctness-fix PR, not here: the 14:50 truncation is a standing research-side authorization (2026-07-19: "全日数据因子一律截断到 14:50…授权直接截断,不必逐因子再问") and this factor predates it, so truncating is applying an existing rule rather than making a new research decision — but from the refactor's point of view it still changes a published factor's values, and §〇 forbids the refactor changing definitions. Both things are true; the resolution is that it gets its own PR, labelled a correctness fix.
What this PR does contain
6046bbeC3 — evaluation contract v1.0:EvalConfig.view/return_basiswith pairing legality enforced at construction via D0'srequire_legal_pairing(rule delegated, not restated); provenance gainsrequires/adjustment/overnight_boundary/lookback_depthplus a top-leveleval_contract_version;analytics/eval/summary.pyrefuses to render a cross-basis summary that omitsview/return_basis(declaring the columns but leaving them blank is refused too). The verdict gates were not touched: the reviewer confirmedverdict.py/sections.py/standard.py/ir.py/evaluator.py/report.pyare md5-identical to main, and every default (min_abs_icir=0.30,min_incremental_abs_icir=0.15,min_monotonicity_spearman=0.0,min_rebalances=12,min_effective_samples=24.0,min_span_days=365,min_ic_win_rate=0.55,min_abs_nw_t=2.0) is pinned by value in tests.0363f0cDiagnostics channel for the coverage disclosures the unified runner must carry:per_symbol_diagnosticsis a separate callable fromper_symbol, so the values path D4b verified cell-by-cell can never be perturbed by a diagnostics request (reviewer's mutation: multiply diagnosed values by1+1e-9→ the isolation test catches it). Three of eleven factors carrydiagnostics_out, and the store persists values only, so a disclosure request must recompute — that cost is written into behaviour and pinned (test_a_warm_store_serves_values_but_a_sink_forces_the_recompute), because the alternative is dropping a mandatory section, i.e. silent degradation.cc06d32A defect the implementer found in its own work two commits earlier and fixed in its own commit:EvalConfig's identity default must be the legacy pairing, because the eleven runners each build one config and feed both the close and the exec side; defaulting to decision/exec would have made every close artifact declare a basis it was not scored on.c6c03af14:50 visibility property tests +qt/panel_leg_probe.py(the tool that produced the panel-leg evidence above).babcc9bReview fixes — see below.Review (adversarial): APPROVE-WITH-NITS, all findings closed
MEDIUM-1 — a guard that could not guard itself. The test claiming to pin
cc06d32's fix was a substring match on function source (assert "replace(" in source and "EXEC_TO_EXEC" in source ...). The reviewer emptied the real assignment into a barereplace(eval_cfg)and left the two names in comments: every exec artifact would then inherit the caller's close identity — declaringview=close / close_to_closewhile being scored on exec returns — and 52 tests across all three relevant files passed. This is #78's lesson again (a lexical assertion cannot hold a semantic claim), failing in the direction that re-admits the very defect the same commit had just fixed. Fixed by extractingexec_identity(cfg, *, factor_id, book_view)and asserting behaviour. Reviewer re-ran the identical escape on the new code: 4 failed, rc=1, red at two levels; and separately verified the false-positive direction is gone (moving the same call into a helper — behaviour identical, literal names gone — stays green at 58 passed).MEDIUM-2 — a false claim one level up. The exec path declared
view=decisionunconditionally, but that is untrue forwith_bookruns, whose factor book (value_ep / value_bp / volatility_20) is taken at close(d) plus that evening's daily_basic — the live defect §1.1 itself documents and defers to D7. The field is defined as "the information-set view the factor values were taken under", and a with-book run carries two information sets; one field silently reported the clean one. This is precisely the class of defect the field exists to prevent, one level up, and it was free to fix now because no artifact carries the field yet — once C5/C6 runs it would be baked into 22 of them.Fixed by splitting the two facts rather than registering an exception (registering alone cannot make
view=decisiontrue for with_book): a newEvalConfig.book_viewdeclared by the only informed party, andsubject_view(factor_id)derived from measured fact — the deny listNOT_DECISION_CUTOFF_SAFElives on the factor side (it is a fact about values, not about the evaluation path) and is measured by the visibility test rather than declared; the test's local copy was deleted, since two copies are two things to keep in sync. Reviewer's mutations: emptying the deny list → 3 failed, including the measurement test itself catchingjump_amount_corras dirty; revertingsubject_viewto a hardcoded constant → 1 failed.Deliberately not validated:
book_view×return_basispairing. The reviewer's adjudication is worth recording as the general rule — a mismatch that destroys meaning must be blocked; a mismatch that only introduces a disclosed, sign-known bias must be recorded. Aview×basismismatch scores values from one information set against returns anchored in another: no honest artifact exists. A close-view book under exec returns is a control variable on the Incremental axis whose bias direction is pre-registered (§1.1/R24: the book is a mediator, so the current basis understates with_book incremental ICIR) — blocking it would not unmix the information sets, it would only stop the artifact from saying that they are mixed.A deliberate loud block ships with this:
jump_amount_corr_20now derives(close, exec_to_exec), whichEvalConfigrefuses, so its exec evaluation raises. That is correct — until the truncation lands, no honest exec artifact for it exists, and blocking beats a false claim (red line #9). The message names the factor, the derived view, the mechanism, the measurement source, and the consequence, and chains the generic pairing error as__cause__. Removal is one deny-list entry, tracked by the correctness-fix PR.The implementer then found the same shape one level below, in its own new tests, and reported it rather than quietly patching: a unit test on
exec_identitycannot speak to how the caller wires it — passingbook_view=Nonefor the with-book run (book supplied, report says none) left 28 tests green. End-to-end assertions now read the two actually-written JSON files, check the rendered Markdown, and assert the caller's own config is not written back. Reviewer added a second mutation closer to a real regression (both runs sharing one config) — also red.LOW-1 (a test named
..._is_authored_oncethat asserted formatting, not author-once) renamed; the property does hold.Lead independent gates (re-run after the reviewer finished mutating, since a lead measuring a tree a reviewer is mutating is its own hazard): pytest 2227 passed, 1 skipped (2228 collected; main 2175 → +46 → 2221 → +7; 0 existing tests displaced) · ruff clean · phase0 anchor
ic 0.9600 / annual 0.8408· validate-config 31/31 · secret scan 0 ·exec_baseline_freeze --verify77/77 at45c14aa· frozen panels untouched · worktree clean.Registered for C5, before any artifact is regenerated
docs/factors/d5_runner_difference_catalogue.md§7b now records the drift this PR causes, so reconciliation does not chase it as a regression:eval_config+3 keys, one top-level key, four Markdown lines — all additive, so C5's byte-level comparison is known not to hold and value-level comparison plus structural assertions is what can. Two further entries: the exec side's no_book and with_bookeval_configblocks now legitimately differ (nullvs"close") and must not be read as a regression between them; and jump's exec evaluation now raises by design, with the release condition spelled out — otherwise someone will later see two artifacts missing and assume the run broke.Open items this PR does not touch (each ruled on, each queued separately)
jump_amount_corrtruncation + withdrawing/restating its published exec verdict — separate correctness-fix PR, in flight. Its priority is now effectively raised: with the loud block in place its exec evaluation cannot run at all, so C5/C6 would otherwise be missing those two artifacts._ensure_coveragecomputes gaps by date only, not (date, symbol), so a second batch finds "no gap" and its symbols vanish from the cross-section — affecting all eleven factors, and it is the very red line amendment A1 invoked to reject caller-side batching; andintraday_amp_cut's z-score happens at materialization, before the store, so stored values are a function of the universe while the key says otherwise (measured: fill with 12 symbols, read with 24 → 0 provider calls, 24 rows instead of 48, all 24 differing, max|Δ| 0.159–0.186). Queued as D4c, a prerequisite for C4. Adding a universe dimension to the key was rejected: it would over-invalidate the ten universe-independent factors, and a PIT universe is not a single set over a date range, so the dimension is not even definable — the store should hold per-symbol intermediates and combine cross-sectionally at read.valley_price_quantiledouble lag — the proposed third path (shifted panel + the factor's internal T-1 disabled) was measured bit-identical (max|diff| = 0.000e+00,Series.equalsTrue) with a breaking control at 7.3e-2, so it is an implementation change and is authorized — with two landing conditions: the equivalence test must pin both sides (it relies on both being per-symbol positional shifts on one date grid), and the shifted panel needs one extra day of history at the left edge.300114.SZhas no minute bars — the frozen panels themselves prove it: the three daily book-factor panels carry 996 including it, the ten minute panels carry 995 without it), andqt/saturation_probe.py's default universe derives from a 995 panel.Two non-blocking NITs recorded: the block fires after the runner's expensive minute aggregation rather than at entry (self-resolving once the truncation lands and the deny-list entry goes); and
IDENTITY_FIELDSdoes not yet includebook_view, which will matter the first time a summary puts no_book and with_book side by side.No CLAUDE.md/AGENTS.md changes here (docs PR follows). Frozen baselines untouched.